<?php
/**
 * Class for input data validation.
 * 
 * LICENSE:
 * 
 * This library is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with This library. If not, see <{@link http://www.gnu.org/licenses/}>.
 *
 * @link http://code.google.com/p/phpstartapp/
 * @author Bojan Mauser <bmauser@gmail.com>
 * @copyright Bojan Mauser 2009
 * @package psa
 * @version $Id: Psa_User.class.php 188 2008-12-03 16:51:00Z bmauser $
 */


/**
 * Class for input data validation.
 * You should validate input data from users that you sent to model methods. Methods in this class can help you with that.
 * Validation should occur in the model method itself, not in controller (or in some action method). That makes your model
 * methods more standalone, and more reusable in some future projects.
 * 
 * <b>Example:</b>
 * 
 * <code>
 * <?php
 * 
 * // model plugin
 * class search extends Psa_Plugin_Model{
 * 	
 * 	// search method
 * 	function filter($search_word, $max_results, $only_pages){
 * 			
 * 		// input validation
 * 		$validator = new Psa_Validator();
 * 		$validator->add_required ($search_word,'string');
 * 		$validator->add_optional ($max_results,'int');
 * 		$validator->add_optional ($only_pages,'int_array');
 * 		$validator->validate();
 * 		
 * 		// Method logic starts here. For example to query database
 * 		// using $search_word and $max_results
 * 	}
 * }
 * 
 * ?>
 * </code>
 * 
 * @see validate();
 */
class Psa_Validator{
	
	
	/**
	 * Array that holds values for validation
	 * 
	 * @var array
	 * @ignore 
	 */
	protected $validate_values = array();
	
	
	/**
	 * Array that holds error messages form validation
	 * 
	 * @var array
	 * @ignore 
	 */
	protected $messages = array();
	
	
	/**
	 * If set to true no exception will be thrown on validation error error
	 * 
	 * @var bool
	 * @ignore 
	 */
	protected $no_exeptions = false;


	/**
	 * You can disable throwing exceptions if you pass <kbd>true</kbd> as argument.
	 * 
	 * @param $no_exeptions if set to true no exception will be thrown on validation error
	 * @ignore 
	 */
	public function __construct($no_exeptions = false){
		if($no_exeptions)
			$this->no_exeptions = true;
	}
	

	/**
	 * Adds values for later validation with {@link validate()} method.
	 * See example in {@link Psa_Validator class description}.
	 * @param mixed $value value to be evaluated.
	 * @param string $type can be <kbd>int</kbd>, <kbd>id</kbd>, <kbd>date</kbd>, 
	 * <kbd>string</kbd>, <kbd>email</kbd>, <kbd>ip4</kbd>, <kbd>ip6</kbd>, <kbd>float</kbd>, <kbd>alpha</kbd>, 
	 * <kbd>num</kbd>, <kbd>alphanum</kbd>, <kbd>regex</kbd>, <kbd>domainsafe</kbd>, 
	 * <kbd>invalues</kbd>, <kbd>url</kbd>, <kbd>between</kbd>, <kbd>lenbetween</kbd>, 
	 * <kbd>equal</kbd>, <kbd>identical</kbd>, <kbd>hostname</kbd>, <kbd>callback</kbd>
	 * 
	 * If <var>$value</var> is an array, suffix <kbd>_array</kbd> can be added to any of these 
	 * types to validate each element of array <var>$value</var>. For example, this is useful 
	 * when validating input from HTML drop-down menu with multiple selections.
	 * 
	 * @param mixed $param2 if set it will be passed as second parameter to <i>is_</i> methods where needed. 
	 * Like with <kbd>date</kbd> and <kbd>regex</kbd> validation types.
	 * @param mixed $param3 like <kbd>$param2</kbd>
	 * @see add_optional()
	 * @see validate()
	 */
	public function add_required($value, $type, $param2 = null, $param3 = null){
		
		$this->validate_values[] = array('value' => $value, 'type' => $type, 'req' => 1, 'p1' => $param2, 'p2' => $param3);
	}


	/**
	 * This method does the same as {@link add_required()} method and only difference is that 
	 * evaluated value can be empty string or null.
	 * For example this can be useful for values from optional HTML form elements.
	 * See example in {@link Psa_Validator class description}.
	 * 
	 * @param mixed $value
	 * @param string $type
	 * @param mixed $param2 
	 * @see add_required()
	 * @see validate()
	 */
	public function add_optional($value, $type, $param2 = null, $param3 = null){

		$this->validate_values[] = array('value' => $value, 'type' => $type, 'req' => 0, 'p1' => $param2, 'p2' => $param3);
	}
	

	/**
	 * Returns array with unsuccessful validations and error messages from {@link validate()} method.
	 * It will return 0 if all validations are passed.
	 * 
	 * @see validate()
	 * @return int|array
	 * @ignore 
	 */
	public function get_errors(){
		if(sizeof($this->errors))
			return $this->errors;
		else return 0;
	}
	
	
	/**
	 * Just throws Psa_Validation_Exception
	 * 
	 * @param string $message exception message
	 * @param int $code exception code
	 * @throws Psa_Validation_Exception
	 */
	public function fail($message = '', $code = 63){
		include_once 'Psa_Validation_Exception.class.php';
		throw new Psa_Validation_Exception($message, $code);
	}
	
	
	/**
	 * Throws Psa_Validation_Exception and sets $this->errors.
	 * @ignore 
	 */
	protected function fail_local($message, $value, $type, $code, $msg_index){
		
		$this->errors[$msg_index]['desc'] = $message;
		$this->errors[$msg_index]['data'] = $value;
		$this->errors[$msg_index]['type'] = $type;
		
		if($this->no_exeptions)
			return false;
		
		include_once 'Psa_Validation_Exception.class.php';
		throw new Psa_Validation_Exception($message, $code);
	}
	
	
	/**
	 * Checks if value if empty
	 * @ignore 
	 */
	protected function is_empty($value){
		
		if($value === '' or $value === null or (is_array($value) && empty($value)))
			return true;
			
		return false;
	}
	
	
	/**
	 * Validates all values set by {@link add_required()} and {@link add_optional()} methods.
	 * If validation of any value fails raises exception (or returns false, see {@link __construct()}), 
	 * otherwise returns true. See example in {@link Psa_Validator class description}.
	 * It will throw {@link Psa_Validation_Exception} when first validation fails. For example, you can 
	 * catch this exception in controller and call another action.
	 * 
	 * @see add_required()
	 * @see add_optional()
	 * @return bool
	 * @throws Psa_Validation_Exception
	 */
	public function validate(){

		// reset array with errors
		$this->errors = array();
		
		foreach($this->validate_values as $msg_index => $data){
			
			// check if value can be empty
			if($this->is_empty($data['value'])){	
				// if $data can be empty (optional)
				if(!$data['req'])
					continue;
				// data is required
				else{
					return $this->fail_local("value is required for '{$data['type']}' validation", $data['value'], $data['type'], 63, $msg_index);
				}	
			}
			
			// if validation has to be run on array of elements
			if(substr($data['type'], -6) == '_array'){
				
				if(is_array($data['value'])){
					
					// find type of validation
					$type_arr = explode('_',$data['type']);
					
					// validate each element
					foreach ($data['value'] as $data_key => $data_value){
						
						// check if value can be empty
						if($this->is_empty($data_value)){	
							// if $data can be empty (optional)
							if(!$data['req'])
								continue;
							// data is required
							else{
								return $this->fail_local("value is required for '{$data['type']}' validation", $data_value, $data['type'], 63, $msg_index);
							}	
						}
						
						$this->validate_by_type(array('value' => $data_value, 'type' => $type_arr[0], 'req' => $data['req'], 'p1' => $data['p1']), $msg_index);
					}
				}
				else{
					return $this->fail_local("'{$data['value']}' is not an array thus cannot validate each element", $data['value'], $data['type'], 60, $msg_index);
				}
			}
			else{
				$this->validate_by_type($data, $msg_index);
			}
		}
		
		if(sizeof($this->errors))
			return false;
		else
			return true;
	}

	
	/**
	 * Invokes validation functions and puts error messages into $this->errors array
	 * 
	 * @param array $data data for validation. See add_required() and add_optional() functions
	 * @param int $msg_index index of $this->validate_values array
	 * @ignore 
	 */
	protected function validate_by_type($data, $msg_index){
		
		$message = null;
			
		switch($data['type']){
				
			case 'int':
				if(!$this->is_int($data['value'])){
					$message = "'{$data['value']}' is not an int";
				}
				break;
			case 'id':
				if(!$this->is_id($data['value'])){
					$message = "'{$data['value']}' is not database id";
				}
				break;
			case 'string':
				if(!$this->is_string($data['value'])){
					$message = "'{$data['value']}' is not a valid string";
				}
				break;
			case 'date':
				if(!$this->is_date($data['value'], $data['p1'])){
					$message = "'{$data['value']}' is not a valid date in format " . $data['p1'];
				}
				break;
			case 'email':
				if(!$this->is_email($data['value'])){
					$message = "'{$data['value']}' is not a valid email";
				}
				break;
			case 'ip4':
				if(!$this->is_ip4($data['value'])){
					$message = "'{$data['value']}' is not a valid IPv4 address";
				}
				break;
			case 'ip6':
				if(!$this->is_ip6($data['value'])){
					$message = "'{$data['value']}' is not a valid IPv6 address";
				}
				break;
			case 'float':
				if(!$this->is_float($data['value'])){
					$message = "'{$data['value']}' is not a valid float number";
				}
				break;
			case 'alpha':
				if(!$this->is_alpha($data['value'])){
					$message = "'{$data['value']}' does not contains only alpha characters";
				}
				break;
			case 'alphanum':
				if(!$this->is_alphanum($data['value'])){
					$message = "'{$data['value']}' does not contains only alphanumeric characters";
				}
				break;
			case 'num':
				if(!$this->is_num($data['value'])){
					$message = "'{$data['value']}' does not contains only numeric characters";
				}
				break;
			case 'regex':
				if(!$this->check_regex($data['value'], $data['p1'])){
					$message = "'{$data['value']}' does not match patern {$data['p1']}";
				}
				break;
			case 'domainsafe':
				if(!$this->is_domainsafe($data['value'])){
					$message = "'{$data['value']}' does not contain only domain name safe characters";
				}
				break;
			case 'invalues':
				if(!$this->is_invalues($data['value'], $data['p1'])){
					$message = "'{$data['value']}' is not in " . implode(',',$data['p1']);
				}
				break;
			case 'between':
				if(!$this->is_between($data['value'], $data['p1'],$data['p2'])){
					$message = "'{$data['value']}' is not in between {$data['p1']} and {$data['p2']}";
				}
				break;
			case 'lenbetween':
				if(!$this->is_lenbetween($data['value'], $data['p1'], $data['p2'])){
					$message = "Length of '{$data['value']}' is not in between {$data['p1']} and {$data['p2']}";
				}
				break;
			case 'hostname':
				if(!$this->is_hostname($data['value'])){
					$message = "'{$data['value']}' is not valid hostname";
				}
				break;
			case 'url':
				if(!$this->is_url($data['value'])){
					$message = "'{$data['value']}' is not valid url";
				}
				break;
			case 'callback':
				if(!$this->callback($data['p1'], $data['value'], $data['p2'])){
					$message = "validation with {$data['p1']}() failed.";
				}
				break;
			case 'equal':
				if(!$this->equal($data['value'], $data['p1'])){
					$message = "'{$data['value']}' is not equal(==) {$data['p1']}";
				}
				break;
			case 'identical':
				if(!$this->identical($data['value'], $data['p1'])){
					$message = "'{$data['value']}' is not identical(===) {$data['p1']}";
				}
				break;
			default:
				$message = "Unknown validation type: {$data['type']}";
				break;
		}
		
		if($message){
			return $this->fail_local($message, $data['value'], $data['type'], 61, $msg_index);
		}
		
		return true;
	}
	

	/**
	 * Finds whether the given <var>$value</var> is between (or equal) $min and $max.
	 * This method can be called statically.
	 * 
	 * @param float $value the variable being evaluated.
	 * @param float|bool $min if null only <kbd>$max</kbd> argument is evaluated
	 * @param float|bool $max if null only <kbd>$min</kbd> argument is evaluated
	 * @return bool
	 */
	public function is_between($value, $min = null, $max = null){
		
		if($value === '')
			return false;
		
		if($min !== null && $max !== null && $value >= $min && $value <= $max)
			return true;
		else if($min === null && $max !== null && $value <= $max)
			return true;
		else if($min !== null && $max === null && $value >= $min)
			return true;
		
		return false;
	}
	
	
	/**
	 * Finds whether the length of the given <var>$value</var> is between (or equal) $min and $max.
	 * This method can be called statically.
	 * 
	 * @param string $value the variable being evaluated.
	 * @param int|bool $min if null only <kbd>$max</kbd> argument is evaluated
	 * @param int|bool $max if null only <kbd>$min</kbd> argument is evaluated
	 * @return bool
	 */
	public function is_lenbetween($value, $min = null, $max = null){
		
		$length = strlen($value);
		
		if($min !== null && $max !== null && $length >= $min && $length <= $max)
			return true;
		else if($min === null && $max !== null && $length <= $max)
			return true;
		else if($min !== null && $max === null && $length >= $min)
			return true;
		
		return false;
	}
	
	
	/**
	 * Finds whether the given <var>$value</var> is in array passed as second argument.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @param array $values array of valid values
	 * @return bool
	 */
	public function is_invalues($value, $values){
		
		if(in_array($value,$values))
			return true;
		else
			return false;
	}
	
	
	/**
	 * Checks is the given <var>$value</var> an integer.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_int($value){
		
		if(is_bool($value))
			return false;
		if(strval(intval($value)) !== (string) $value)
			return false;
		return true;
	}

	
	/**
	 * Finds whether the given <var>$value</var> is integer bigger than 0.
	 * Positive integer is often used as primary key in database tables therefore name <i>is_id</i>.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_id($value){
		if(Psa_Validator::is_int($value) && $value > 0)
			return true;
		return false;
	}


	/**
	 * Checks date if matches given format and validity of the date.
	 * This method can be called statically.
	 * Examples:
	 * 
	 * <code>
	 * Psa_Validator::is_date('22.22.2222', 'mm.dd.yyyy'); // returns false
	 * Psa_Validator::is_date('11/30/2008', 'mm/dd/yyyy'); // returns true
	 * Psa_Validator::is_date('30-01-2008', 'dd-mm-yyyy'); // returns true
	 * Psa_Validator::is_date('2008 01 30', 'yyyy mm dd'); // returns true
	 * </code>
	 * 
	 * @param string $value the variable being evaluated.
	 * @param string $format Format of the date. Any combination of <i>mm</i>, <i>dd</i>, <i>yyyy</i> 
	 * with a single character separator between.
	 */
	public function is_date($value, $format = 'mm.dd.yyyy'){
		
		if(strlen($value) == 10 && strlen($format) == 10){
			
			// find separator. Remove all other characters from $format
			$separator_only = str_replace(array('m','d','y'),'', $format);
			$separator = $separator_only[0]; // separator is first character
			
			if($separator && strlen($separator_only) == 2){
				// make regex				
				$regexp = str_replace($separator, "\\" . $separator, $format);
				$regexp = str_replace('mm', '[0-1][0-9]', $regexp);
				$regexp = str_replace('dd', '[0-3][0-9]', $regexp);
				$regexp = str_replace('yyyy', '[0-9]{4}', $regexp);
				
				if($regexp != $value && preg_match('/'.$regexp.'/', $value)){
	
					// check date			
					$day   = substr($value, strpos($format, 'd'), 2);
					$month = substr($value, strpos($format, 'm'), 2);
					$year  = substr($value, strpos($format, 'y'), 4);
					
					if(@checkdate($month, $day, $year))
						return true;
				}
			}
		}
		return false;
	}


	/**
	 * Finds whether the given <var>$value</var> has valid email format.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_email($value){

		if(preg_match('/^[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,64}$/i', $value))
			return true;
		return false;
	}


	/**
	 * Checks is the given <var>$value</var> valid ip v4 address.
	 * This method can be called statically.
	 * 
	 * @param string $value the variable being evaluated.
	 * @return bool
	 */
	public function is_ip4($value){

		$value = (string) $value;
		
		$ip2long = ip2long($value);
		
		if(($ip2long === false) || (long2ip($ip2long) !== $value)){
			return false;
		}
		
		return true;
	}


	/**
	 * Checks is the given <var>$value</var> valid floating point number.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_float($value){

		if(strval(floatval($value)) != (string) $value){
			return false;
		}
		
		return true;
	}

	
	/**
	 * Checks whether the given <var>$value</var> is valid string.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_string($value){

		if(is_array($value) or is_object($value) or $value === '')
			return false;
		
		if(strval($value) == $value){
			return true;
		}
		
		return false;
	}
	
	
	/**
	 * Checks whether the given <var>$value</var> contains letters only.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_alpha($value){
		
		if(!is_string($value))
			return false;
			
		if(preg_match('/^[a-zA-Z]+$/', $value))
			return true;
		return false;
	}
	
	/**
	 * Checks whether the given <var>$value</var> contains numbers only.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_num($value){
		
		if(preg_match('/^[0-9]+$/', $value))
			return true;
		return false;
	}
	
	
	/**
	 * Checks whether the given <var>$value</var> contains alphanumeric characters only.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 */
	public function is_alphanum($value){

		if(is_array($value) or is_object($value))
			return false;
		
		if(preg_match('/^[a-zA-Z0-9]+$/', $value))
			return true;
		return false;
	}
	
	
	/**
	 * Checks whether the given <var>$value</var> contains only domain name safe characters only.
	 * Those are: letters, numbers, hyphens. Dot '.' is not included cause it separates domain
	 * levels thus it is not part of domain name.
	 * This method can be called statically.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 * @see is_hostname()
	 */
	public function is_domainsafe($value){

		if(is_array($value) or is_object($value))
			return false;
			
		if(preg_match('/--|\.|^-|-$|^[0-9]+$/', $value))
			return false;
		if(preg_match('/^[a-zA-Z0-9\-]+$/', $value))
			return true;
		return false;
	}
	
	
	/**
	 * The same as {@link is_domainsafe} but dot '.' is also included.
	 * 
	 * @param $value the variable being evaluated.
	 * @return bool
	 * @see is_domainsafe()
	 */
	public function is_hostname($value){

		if(is_array($value) or is_object($value))
			return false;
		
		if(preg_match('/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,64}$/', $value))
			return true;
		return false;
	}
	
	
	/**
	 * Searches <var>$value</var> for a match to the regular expression given in <var>$pattern</var>. 
	 * This method can be called statically.
	 * 
	 * @param string $value the variable being evaluated.
	 * @param string $pattern complete pattern for PHP <kbd>preg_match()</kbd> function. For example: <i>/^[1-9]+$/</i>
	 * @return bool
	 */
	public function check_regex($value, $pattern){

		if(preg_match($pattern, $value))
			return true;
		return false;
	}
	

	/**
	 * Checks if given <var>$value</var> is valid URL. 
	 * This method can be called statically.
	 * 
	 * @param string $value the variable being evaluated.
	 * @return bool
	 */	
	public function is_url($value){
		
		// taken from: http://daringfireball.net/2010/07/improved_regex_for_matching_urls
		$regex = '^\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,64}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’]))$';		
		if(preg_match('&'.$regex.'&i', $value))
			return true;
		return false;
	}
	
	
	/**
	 * Calls user function or method which has to return true or false.
	 * It calls PHP call_user_func() function. 
	 * This method can be called statically.
	 * 
	 * @param string $param1 user function name
	 * @param mixed $param2 first parameter for user function
	 * @param mixed $param3 second parameter for user function
	 * @return bool
	 */	
	public function callback($param1 = null, $param2 = null, $param3 = null){
		
		return call_user_func($param1, $param2, $param3);
	}
	
	
	/**
	 * Returns true if parameters are equal.
	 * Uses '==' operator for comparison. 
	 * This method can be called statically.
	 * 
	 * @param mixed $param1
	 * @param mixed $param2
	 * @return bool
	 */	
	public function equal($param1, $param2){
		
		if($param1 == $param2)
			return true;
		return false;
	}
	
	
	/**
	 * Returns true if parameters are identical.
	 * Uses '===' operator for comparison. 
	 * This method can be called statically.
	 * 
	 * @param mixed $param1
	 * @param mixed $param2
	 * @return bool
	 */	
	public function identical($param1, $param2){
		
		if($param1 === $param2)
			return true;
		return false;
	}
	
	
	/**
	 * Validates an IPv6 address.
	 *
	 * @param  string $value Value to check against
	 * @return bool True when $value is a valid ipv6 address. False otherwise.
	 */
	public function is_ip6($value){

		$regex = '^(?:(?:(?:[A-F0-9]{1,4}:){5}[A-F0-9]{1,4}|(?:[A-F0-9]{1,4}:){4}:[A-F0-9]{1,4}|(?:[A-F0-9]{1,4}:){3}(?::[A-F0-9]{1,4}){1,2}|(?:[A-F0-9]{1,4}:){2}(?::[A-F0-9]{1,4}){1,3}|[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,4}|(?:[A-F0-9]{1,4}:){1,5}|:(?::[A-F0-9]{1,4}){1,5}|:):(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?:[A-F0-9]{1,4}:){6}:[A-F0-9]{1,4}|(?:[A-F0-9]{1,4}:){5}(?::[A-F0-9]{1,4}){1,2}|(?:[A-F0-9]{1,4}:){4}(?::[A-F0-9]{1,4}){1,3}|(?:[A-F0-9]{1,4}:){3}(?::[A-F0-9]{1,4}){1,4}|(?:[A-F0-9]{1,4}:){2}(?::[A-F0-9]{1,4}){1,5}|[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6}|(?:[A-F0-9]{1,4}:){1,7}:|:(?::[A-F0-9]{1,4}){1,7})$';
		if(preg_match('/'.$regex.'/i', $value))
			return true;
		return false;
	}
	
}

